Skip to content

feat(model-groups): surface effective modalities and fail early on spawn (issue #26) - #27

Open
grzegorznowak wants to merge 31 commits into
agenticoding:mainfrom
grzegorznowak:feat/spawn-modalities-26
Open

feat(model-groups): surface effective modalities and fail early on spawn (issue #26)#27
grzegorznowak wants to merge 31 commits into
agenticoding:mainfrom
grzegorznowak:feat/spawn-modalities-26

Conversation

@grzegorznowak

@grzegorznowak grzegorznowak commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Implements issue #26 — "Let the spawn system read model modalities to understand the tasks it's good/bad at".

Related to agenticoding/pi-agenticoding issue #26 (spawn reading model modalities). No issue is auto-closed by this PR.

What this PR does

The spawn system and the main session now understand what each Model Group is capable of, and fail early when a delegated task asks for a capability the selected model/group cannot deliver.

Capabilities are pluggable, not hard-coded to modalities.

  • A small model-groups/constraints/ kernel generalizes how model capabilities are derived and checked. One constraint descriptor owns the whole concern for a single capability: how to read the fact off a Model, how to combine it across the group's members, how to reconcile a user override, how to check a spawn requirement, and how to present it in the UI/prompt.
  • Modalities are currently the only registered constraint (production registry is exactly modalities). Future capabilities (e.g. a minimum context-window) can be added as another descriptor plus tests, with no edits to the layer that loads config, routes spawns, or renders the UI. This is demonstrated by a synthetic test-only descriptor that runs through the whole path without ever appearing in production code.

Persisted overrides use a single versioned, forwards-compatible shape.

  • A group's override lives under a generic constraints map on the group (e.g. constraints.modalities).
  • Unknown keys (future capabilities the current plugin does not know about) round-trip unchanged, so a newer config does not degrade.
  • Config version stays 2; since this branch has not shipped v2 yet, no migration path is needed.
  • For existing v1 configs, behavior is unchanged (a v1 modalityOverride key is preserved as opaque data and dropped on the first v2 write).

Spawn fail-early.

  • The spawn tool accepts constraints (a keyed object, schema-generated from the descriptors), normalized at one boundary before routing.
  • The router checks the declared requirements against the routed group's capabilities and the exact selected model. Modality violations keep the exact existing missing-modality SpawnRouteError; unknown requirement keys are rejected; the check holds before any child session is created.

Main-session awareness and TUI.

  • before_agent_start injects each group's capability summary (e.g. websearch (text, image)) into the orchestrator system prompt, reusing the existing hook.
  • The TUI editor and its empty/common + stale-override warnings source from the same constraint layer.

Scope note: intentionally refactor-only

This PR deliberately does not add a cost, minimum-context, or parameter-count feature. It lays the pluggable foundation and exercises it with a synthetic test descriptor so the extension point is proven rather than aspirational. Two constraints of the derivation are handled separately: deciding whether group capability means "all registered members" vs "only authenticated/usable members" is a separate issue, and a parameter-count source does not exist in the host Model type today (model-name inference is not an option).

Review feedback addressed

Review cycle 1 (contract + first implementation review)

  • Legacy configs with a malformed modalityOverride previously crashed load; they now validate cleanly and recover with a .bak backup.
  • Wording of the "stale override" behavior corrected: persisted stale entries are retained/excised on read, while invalid additions are rejected on mutation.
  • Added tests for the load regression, CRUD rejection, the modal editor commit/Automatic interactions, boot notification counts, plain-inherited requiredModalities, and tool-schema validation.

Code-review implementation pass

  • Added a registered spawn-tool test asserting requiredModalities rejection throws before any child session (zero factory calls, both session maps empty).
  • A success-path registered-spawn test now also asserts liveChildSessions is cleared.
  • Documented the locked v1 pass-through no-migration decision and the intended CRUD-only scope of the low-level save helper.
  • Corrected the integration fixture comment (the mock registry's Claude is unresolved; it does not add text/empty-common counts the comment implied).
  • TUI modal-editor commit tests now select rows by rendered label rather than positional row numbers / hard-coded subset counts.

Pluggability review

  • Replaced the modality hard-wiring with the constraint layer + generic envelopes described above.
  • Unsupported/wrong requirement handling, empty-common labeling, stale-override editing, and the CRUD-only scope were confirmed or tightened.

Follow-on capability routing (D1 + D2, on this branch)

D1 — capability-aware model pre-selection in spawn. When a #group spawn declares a non-empty modality requirement (e.g. constraints.modalities.required: ["image"]), resolveSpawnModelRoute now narrows the auth-usable pool to members whose per-model modality fact satisfies it, then round-robins across that capable set via an in-memory per-session cursor (state.spawnRouteCursors) instead of picking uniform-random among all usable members. The earlier failure where an image-capable member existed in the group but a text-only sibling happened to be selected (then rejected as missing-modality) is eliminated. Watch-outs honored: unauthenticated capable models are excluded, overridden group ceilings still govern, and a wholly-incapable group still rejects with missing-modality. Round-robin is threaded only from the spawn path — main-session group switches keep the prior behavior.

D2 — capability chips in the Add-model picker. The WIZARD_MODEL step now renders a per-member colored T I chip (shared modality.ts lexicon + getModalitiesModelFact) next to each model, so capability is visible while adding. The editor member-list rows are unchanged (out of scope).

D3 — Automatic groups default their capability set to the union. Without an explicit override, a group's effective modalities are now the union of its members' individual modality facts (was: the intersection of what every member shares). This fixes a real routing-gate bug: an Automatic mixed group ([text, text+image]) with an image spawn selected the image-capable member via D1 pre-selection, but the group gate then read effective=common=[text] and rejected missingFromGroup=image. With the union default the gate passes and the image-capable member runs. Guarantees kept:

  • No invented capabilities: if no member has a modality (e.g. reasoning), the union does not contain it and the requirement still rejects with the group miss.
  • Explicit override stays an authoritative subtractive ceiling (ADJ-005 Policy B): quick-review-style [text, reasoning] still rejects image spawns even when the routed member itself supports image (regression-tested).
  • common remains the factual every-member editor field (Supported by every model: …); only the reconcile default changes (one line in reconcile).
  • Editor semantics follow: Automatic groups open with their union capabilities active; un-toggling a supported capability writes a subtractive override.

D4 — Per-model capability chips in the editor + limited/unlimited guidance. Two TUI refinements.

  • Editor model rows now carry T/I chips. Each row in the Models section renders the same colored per-member chip as the Add-model picker (D2), derived live from the model registry via getModalitiesModelFact (unresolved members render without a chip). Example:
    deepseek/deepseek-v4-flash T (available, thinking low)
    openai-codex/gpt-5.4 T I (available, thinking low)
    
  • Modalities hand-edit screen explains limited vs unlimited. Two short dim guidance lines now sit under the toggle list, stating the D3 semantics directly:
    • Automatic: the group uses every capability its members support.
    • Override: the group is limited to exactly the listed capabilities.

D5 — Modalities editor rebuilt around disabling capabilities (toggle-union UX). The hand-edit screen drops the "Automatic (…)" selector row and is now built around disabling capabilities from the available union set:

Modalities — group-demo
  T text  [required]
→ I image  [off]
↑↓ navigate • Enter/Space toggle • Esc back
Override — media limited to text.
  • T text [required] is a non-toggleable required base row (text is guaranteed whenever any model is present).

  • Union media capabilities (image) are [on]/[off] toggles; reasoning stays out of the media screen (per-model thinkingLevel, per the earlier "don't collude reasoning with modalities" decision).

  • Toggling off writes a subtractive override from the current effective — hidden reasoning ceilings are preserved (image off on [text,image,reasoning] stores [text,reasoning]).

  • Toggling the last capability back on collapses to Automatic only when the candidate equals the full union, removing the stored override — so no spawn ceiling attaches to a non-limit, and Automatic stays reachable without a selector row.

  • One dynamic status line replaces the two static guidance lines: Automatic — using every capability its members support. / Override — media limited to <media>.

  • Text-only groups show No optional media capabilities available. + Esc back; Enter/Space are inert (no-op).

The generic constraintEditorRows() constraint-layer surface is unchanged; only the TUI screen specializes.

D6 — Modalities editor streamlined to a single toggle + selected-row highlight. Amendments to the D5 toggle-union screen after operator feedback:

  • The per-row — Enter/Space toggles hint is removed (it was noise for the dominant T+I vocabulary).
  • Single-editable-row screens drop ↑↓ navigate; the footer is just Enter/Space toggle • Esc back (arrow navigation returns automatically if a second visible media row is ever added).
  • The selected capability row is now accent-highlighted (accent + accent label, modality letter keeps its own color) — previously the selectableLine accent wrap was neutralized by inner color resets, so the being-edited item appeared unhighlighted.
  • Text-only screens keep No optional media capabilities available. + Esc back; Enter/Space are inert.

Render (T+I):

Modalities — group-demo
  T text  [required]
→ I image  [on]
Enter/Space toggle • Esc back
Automatic — using every capability its members support.

Validation (full battery, all PASS)

  • npm run typecheck
  • npm test (690/690)
  • npm run test:e2e (16/16)
  • npm run test:snapshots:check (11/11)
  • npm run test:compat:current (0.84.2)
  • npm run test:package-host
  • git diff --check clean; no test-only descriptor key leaks into product files; no stray console output

Refers to #26.

Implement issue agenticoding#26: spawn and main session become modality-aware.

- Add pure derivation module model-groups/modalities.ts (common/supported/effective sets from live ModelRegistry input+reasoning).
- Persist per-group modalityOverride with v2 schema-version guard: lossless normalization, opaque-key preservation, v1 in-memory migration, future-version write refusal.
- Inject effective modalities per group into the main-session system prompt via before_agent_start.
- Add optional spawn requiredModalities checked at the router gate; fail early before child session when the routed model/group cannot satisfy a requirement.
- TUI: modality display, empty-common + stale-override warnings, editor for automatic/supported subsets.
- Add focused AC1-AC7 test coverage incl. new model-groups-modalities test.

Validators: typecheck, npm test 618/618, e2e 16/16, snapshots 11/11, compat:floor, package-host all pass.
test:compat:current is skipped (pre-existing host-skew, unrelated; tracked separately).
@grzegorznowak

Copy link
Copy Markdown
Collaborator Author

On the failing current-pi check

The current-pi failure on this PR is not caused by the changes here — it is a pre-existing host-skew issue:

  • test:compat:current installs @earendil-works/pi-*@latest (currently 0.84.x) into a temp copy and typechecks against it, while this repo's devDeps pin 0.82.0.
  • Pi added registerMarkdownTransformer to ExtensionAPI in 0.84.0. The test double in tests/unit/helpers.ts (deliberate compile-time tripwire) and tests/e2e/test-host.ts are not yet synced, producing exactly the two errors this run reports (tests/unit/helpers.ts Type 'true' is not assignable to type 'never'; tests/e2e/test-host.ts missing registerMarkdownTransformer).
  • This reproduces byte-identically on a clean baseline with these changes stashed — zero involvement of this PR's files. The gate is already red on upstream main independently.

Dependency: PR #23 (fix/spawn: harden abort/reset race handling, bump Pi to 0.84.1) addresses exactly this — it adds the registerMarkdownTransformer stub to createTestPI(), modernizes the compat lanes, and bumps Pi to 0.84.1; its current-pi check passes. This PR is intentionally left scoped to the modality feature (no rebase onto #23). Once #23 lands and main picks up the host sync, rebasing this branch (or re-running after merge) should bring current-pi green here without any change to this PR's files.

…se review gaps

A_R: normalizeGroups now runs validateOverride for every accepted source
version, so a malformed hand-added modalityOverride in a legacy (missing/0/1)
config surfaces as a schema-invalid load issue + backup + empty recovery
instead of a raw TypeError from cloneDef. Minimal stabilization; no
v1 valid-override migration feature.

B (coverage, 619->628):
- crud: A1 regression (legacy malformed override), store-level derivation of
  empty-common + stale flags via summarizeBootValidation counts, CRUD gate
  rejects unsupported override on create and combined member-change update
  (0 writes, byte-for-byte unchanged), v2 load rejects non-array/duplicate/
  out-of-vocabulary override
- tui: modality editor commits override + Automatic path through updateGroup,
  error-retention on updateGroup failure
- integration: session_start boot notification counts for empty-common and
  stale overrides
- router: plain inherited route honors requiredModalities with empty no-op
- spawn: tool schema validated via Value.Check, inherited requiredModalities
  forwarded and succeeding when satisfied

PR agenticoding#27 review1 gaps A1/A2 closed (A2 wording corrected in PR description).
@grzegorznowak

Copy link
Copy Markdown
Collaborator Author

Code-review findings (implementation pass) — in scope for this PR

Reviewed against base 4efc9cf; items below are PR-introduced (the pre-existing store hardening cluster — fsync, locking, symlink containment, backup-on-corrupt-overwrite — is filed separately in #28, not blocking here).

1. HIGH — v1 config with a valid hand-added modalityOverride becomes active despite the version gate (regression from the gap-close)

model-groups/store.ts:46:

defineGroup(groups, name, { ...rawDef, models, ...(sourceVersion >= 2 && override.value !== undefined ? { modalityOverride: override.value } : {}) });

The ...rawDef spread copies modalityOverride before the sourceVersion >= 2 conditional, and the conditional only decides whether to re-set it. So a v1 config hand-edited with modalityOverride: ["text"] (a shape that's now valid since validateOverride runs for all versions after the gap-close) loads as v2 with the override active — silently clamping the group's capabilities and potentially cancelling an image-required spawn that automatic common modalities would have allowed.

Suggested fix: strip the field from the spread for sourceVersion < 2, e.g. const { modalityOverride: _drop, ...def } = rawDef; when v1, or destructure it out before spreading.

2. MED — no test exercises requiredModalities rejection through the registered spawn tool

tests/unit/spawn.test.ts covers rejection via direct executeSpawn and schema via Value.Check, but never through registerSpawnTool(...).execute. A wrapper regression that drops/rewrites requiredModalities would stay green. Add a registered-tool invocation with a factory spy asserting SpawnRouteError, zero factory calls, and both session maps empty.

3. MED — success-path spawn test doesn't assert liveChildSessions cleared

The ordinary successful registered-spawn test asserts only childSessions.size === 0; abort/error paths assert both maps. A success-path live-session leak would pass. Assert liveChildSessions.size === 0 on the happy path too.

4. LOW — exported saveModelGroups bypasses the union-cap invariant

createGroup/updateGroup gate overrides against the registry-derived member union, but the exported low-level saveModelGroups has no registry and will persist a syntactically-valid-but-unsupported override (surfaces later only as a "stale override" warning). Either gate at the API boundary or document that cap enforcement is CRUD-only.

5. LOW — spawn group gate derives effective set without hasConfiguredAuth filtering

router.ts group gate uses registry membership only; a modality carried only by an unauthenticated member can pass the group gate. The per-routed-model check backstops this (not exploitable), but worth deciding whether "effective" should mean usable-members-only.

6. LOW — integration fixture mis-describes its own scenario

tests/unit/model-groups-integration.test.ts (empty/stale boot-count test) comments claim claude is text-capable, but the mock registry only contains gpt-5 — Claude is unresolved. The aggregate counts pass under several incorrect implementations; the store-level tests cover this precisely, but the fixture comment should match reality (or the fixture be made real).

7. LOW — brittle TUI editor test

tests/unit/model-groups-tui.test.ts (modality editor commit test) hardcodes row numbering and a long exact keystroke sequence ("row 8 of Automatic + 8 subsets"). Any correct-but-layout-different UI change breaks it. Prefer selecting by rendered label rather than positional keys.


Not blocking / verified fine: derivation algebra, RNG/registry race (gate checks the exact routed model object), pre-factory rejection can't leak child sessions, escaping of persisted names and closed-vocab modality labels, editor focus-retention on error.

Pre-existing HIGH store findings (no fsync, no locking, symlink containment, backup-on-corrupt-overwrite) → tracked in #28.

@grzegorznowak

Copy link
Copy Markdown
Collaborator Author

Code-review findings — all addressed (triage + patch)

Thanks for the thorough pass. Every in-scope item is resolved. Note: the repo restructured during the #23 merge (model-groups/ moved to top-level; src/model-groups paths in the review no longer exist), but all findings reproduced at their current locations.

Validation after patch: typecheck PASS · unit 652/652 (+1 new test) · e2e 16/16 · snapshots 11/11 · compat:current 0.84.2 PASS · package-host PASS · git diff --check clean.

Pre-existing HIGH store findings (fsync/locking/symlink/backup) remain tracked in #28, out of scope here as you filed them.

… TUI editor

- documented the locked v1 valid-override pass-through as intentional (no migration)
- documented saveModelGroups as low-level CRUD-only cap enforcement (no signature change)
- registered spawn-tool test: requiredModalities rejection throws SpawnRouteError
  before any child session (zero factory calls, both session maps empty)
- happy-path registered-spawn test now asserts liveChildSessions cleared
- corrected integration fixture comment: claude unresolved -> empty common, override stale
- TUI modality editor commit test selects rows by rendered label, not row numbers
…bel, stale editor, prose single-source

- store: strip runtime-derived keys (name/scope/sourcePath/modalities/validation)
  at the persistence boundary; opaque user keys + modalityOverride preserved
- tui: draft projection persists only models/override; MODALITIES editor choices
  union supported + stale override members; empty effective labels unambiguous
- index: empty effective renders '(no common modalities)' instead of '(none)'
- prose vocab single-sourced from MODEL_GROUP_MODALITIES (spawn + prompt section)
- router: documented absent == empty requiredModalities semantics
Resolve the pluggability gap with a typed, compile-time constraint
registry + generic envelopes, absorbed into PR agenticoding#27 (unmerged-v2, no
migration; version stays 2).

- constraints/: pure generic kernel (engine/registry/resolution/presentation)
  with injectable registries; production registers only modalities.
- Modality constraint descriptor owns extraction, aggregation,
  reconciliation, requirements, diagnostics, presentation, editor;
  model-groups/modalities.ts becomes thin compatibility façades (parity).
- Persisted envelope: ModelGroupDef.constraints (canonical) +
  modalityOverride (conflict-safe deprecated alias; equal coalesces,
  unequal rejects; unknown slots round-trip opaquely).
- Spawn envelope: constraints (descriptor-generated TypeBox) +
  requiredModalities alias, normalized at one boundary pre-route.
- Router: iterates registry descriptors; modality violations keep the
  exact missing-modality SpawnRouteError arrays; injected scalar routes
  to additive constraint-unsatisfied.
- Prompt/boot-summary/TUI iterate descriptor presentation metadata;
  notification text and (no common modalities) fallback byte-identical.
- AC6 proof: synthetic testMinContext descriptor traverses the full seam
  via router + registered spawn (0 factory calls, both maps empty);
  production registry stays [modalities], no production testMinContext.

Refactor-only: no materialized cost/context/param dimension.

Full battery green: typecheck, unit 669/669, e2e 16/16, snapshots 11/11,
compat:current 0.84.2, package-host, git diff --check.
@grzegorznowak

Copy link
Copy Markdown
Collaborator Author

Debt review — all 7 items triaged + resolved (Option C landed)

Reviewed the operator's code-debt pass against the current tree (head 0091fdc, after the B29 debt-easy fixes). All seven PR-specific items are addressed:

#1 (HIGH, pluggability gap) — resolved via Option C. The modality vertical slice (vocab, derivation, persistence, validation, boot counts, TUI editor, spawn schema, route gate) is now backed by a typed, compile-time constraint registry with generic persisted override and spawn requirement envelopes (model-groups/constraints/). Modalities are the first registered constraint; future dimensions (e.g. min-context) add one descriptor + tests, with zero consumer edits — proven structurally in tests by a synthetic scalar testMinContext descriptor traversing resolution → aggregate → persisted codec → reconcile/diagnostic → prompt/editor → group+exact-model via the registered spawn tool (0 factory calls, both session maps empty), while the production registry stays exactly ["modalities"]. Per operator decision this is refactor-only: no cost/context/param-count dimension is materialized.

#2 (four divergent group semantics) — deferred with the auth dimension (deferred debt #2). The engine now receives an explicit ConstraintMemberResolution snapshot; auth-aware aggregation remains a recorded, separate operator decision (not silently resolved here).

#3 (TUI → config projected pollution) — verified fixed by the earlier B29 derived-key strip (persisted config carries only authored/persisted + the new generic envelope; runtime-derived keys and the old modalityOverride/constraints raw are stripped at the save boundary).

#4 (CRUD-only cap) — documented. Generic envelope normalization runs against the registry before mutation; low-level saveModelGroups remains a documented CRUD-only primitive (rename/delete/move are intentionally out of cap scope).

#5 ("name (none)" collision + stale-override editor blind spot) — fixed: the empty-effective prompt label is now unambiguous (no common modalities), and the modality editor builds choices over supported ∪ current override members so stale entries stay visible/selectable.

#6 (requiredModalities machinery) — reconciled: schema is descriptor-generated from the registry; prose stays modality-specific by design (refactor-only); empty == absent documented; the router rejects unknown requirement keys before session creation.

#7 (unbounded uncached derivation) — still deferred (perf), tracked separately. Not resolved by this refactor; a cache/version boundary is a separate follow-up.

All prior review items and B-cycle fixes remain green. Full battery at cd38b (this head): typecheck, unit 669/669, e2e 16/16, snapshots 11/11, compat:current 0.84.2, package-host, git diff --check clean, console scan clean.

…ses (clean v2 surface)

Since v2 never shipped, the alias layer had no audience. Make the
generic 'constraints' envelope the single public surface:

- ModelGroupDef: constraints only; modalityOverride removed.
- Spawn tool: constraints only; requiredModalities removed from
  schema, SpawnParameters, and the normalizer.
- Router: requirements come only via constraints.
- Store/TUI/modalities: read/write constraints.modalities only.
- v1 files: a modalityOverride key is preserved opaquely (not
  interpreted) and dropped on the first v2 mutation; a constraints
  key in legacy config is still rejected.
- Prompt guidance now instructs passing requirements as constraints.
- Tests migrated to the constraints shape; alias-conflict/coalesce
  tests replaced with canonical round-trips + B1 legacy-opaque tests.

Full battery green: typecheck, unit 669/669, e2e 16/16, snapshots
11/11, compat:current 0.84.2, package-host, git diff --check.
@grzegorznowak
grzegorznowak marked this pull request as draft August 21, 2026 16:28
Replace the trailing per-group dim footer ('name: modalities ...') with
colored inline chips appended to each group's select label, drawn from the
effective modality set in vocab order (accent=text, success=image,
thinkingHigh=reasoning). Add a single compact legend line above the list.
Respects B29 token discipline (no console.*, no bare ANSI, escaped labels).
…th thinking effort

Per operator ADJ-001 tweaks:
- Move modality markers out of the select label into the description column,
  aligned with the thinking-effort segment (same inline position).
- Use single colored letters per modality instead of full words: T=text,
  I=image, R=reasoning.
- Recolor text modality from accent (teal) to syntaxKeyword (blue) so it no
  longer collides with the selected-row highlight, which also wraps in accent.
- Legend now reads 'modalities: T text · I image · R reasoning' with colored
  letters. Each description segment re-asserts dim after inner colored spans
  so the surrounding dim survives the fg reset (fg resets to terminal default).
- Update unit test to scope single-letter assertions to the review row.
… modalities UI

ADJ-002 (approved) + option A:
- Replace the power-set enumeration in the MODALITIES screen with an
  Automatic row (reset; [✓] while default) + one toggle row per media
  modality (text/image). Enter on a toggle flips that modality in/out,
  seeding from the current effective set on the first toggle so an
  automatic group transitions to an explicit override smoothly.
- New ConstraintEditorRow kind 'toggle' in constraints/presentation.ts;
  multi-select rows now enumerate automatic + one toggle per choice
  instead of the 2^n subset list. Activation builds the next override
  (vocab-ordered) from effective plus/minus the pressed modality.
- Reasoning is a distinct pre-existing capability (per-member thinkingLevel
  + routing gate), so it is excluded from the modality presentation: the
  MODALITIES screen rows, the list legend/chips, and the EDITOR summary
  lines all show only text/image. Kernel/schema/router unchanged; any
  persisted reasoning override value is preserved (never clobbered).
- Add generic multi-select editor-shape test; re-baseline the TUI modal
  editor commit test to toggle individual modalities.
ADJ-002 follow-up:
- Enter on a modality toggle (or the Automatic reset) now persists the change
  and STAYS on the MODALITIES screen so the user can keep toggling, instead of
  bouncing back to the group editor. Esc still closes back to the editor.
- Space key now toggles the selected modality as well as Enter (both dispatch
  to activate on the MODALITIES screen).
- Add a dim hint line: 'up/down navigate • Enter/Space toggle • Esc close'.
- Refactor updateDraft into persistDraft (persist + refresh + re-resolve, no
  navigation) + updateDraft (navigates on success). The MODALITIES toggle uses
  persistDraft and re-binds the edit draft to the refreshed group. Preserves
  original stay-put-on-error semantics (afterSuccess only runs on success).
- Tests: rework commit test to assert stay-on-screen + Esc exit; add a Space
  toggle test.
Make text a structurally guaranteed base capability (matching the invariant
that image/reasoning both imply text). reconcile now always keeps text in the
effective set whenever the group supports it, regardless of override. This
eliminates the empty-group asymmetry: deselecting every modality now yields a
text-only group (effective=[text]) rather than effective=[] that let text
spawns through while denying image/reasoning.

MODALITIES screen becomes thin: a fixed non-selectable 'text [always]' line
plus the Automatic reset and toggles for additive capabilities only (image;
reasoning stays per-model via thinkingLevel, text is the base).

Tests: update unresolved-member and override-effective expectations to include
always-present text; add a backstop-invariant test; adjust TUI text-row
assertion to '[always]'.
Modality-specific usability follow-up only (from #planner UX review); general
TUI debt left untouched and tracked separately.

- MODALITIES screen title now names the group: 'Modalities — <name>' instead
  of the bare 'MODALITIES'.
- Fix hint wording: 'Esc close' -> 'Esc back', 'Enter/Space toggle' ->
  'Enter/Space apply' (the Automatic row resets rather than toggles).
- Static text row: align with selectable rows and reword '[always]' ->
  'required base' so it describes the invariant without claiming an
  empty/all-unavailable group is usable.
- EDITOR modality line: drop 'Common:' jargon -> 'Supported by every model:'.
- Descriptor automatic label: 'Automatic (common: X)' -> 'Automatic (X)'
  (the 'common:' vocabulary was redundant; text is now the always-present
  base via the kernel invariant).
- Update TUI + constraints test expectations for the new wording/labels.
…odels in editor

(#code-review design) Give the group editor two compact static section bars so
the modalities config reads as its own stand-alone block distinct from the
model membership:

  Model Group: review
    Location: project / global
    Name: review
  -- Capabilities --
    Supported by every model: text
    Modalities: Automatic (text, image)
  -- Models --
    provider/model (available, thinking X)
    + Add model...

- 'Capabilities' bar groups the group-level modality policy ('Supported by
  every model' + the Modalities action).
- 'Models' bar heads the member list/Add row.
- Bars and the 'Supported by every model' line are static/non-selectable, so
  all logical row indices and Enter targets are unchanged (only visual bars
  added; zero keyboard rework).
- Automatic/Override now capitalized in the editor summary line.
- Theme discipline kept: dim rules, accent labels, no bare ANSI, no chips in
  the editor (full names are more legible here; LIST chips/legend unchanged),
  MODALITIES screen + constraint kernel untouched.
- Update editor test assertions (Override/Automatic casing, add Capabilities/
  Models coverage).
…ete (ADJ-005)

Share the modality letter/color lexicon in model-groups/modality.ts and have the
#-mention suggestion tooltip prepend each group's effective media-modality letters
(T=text I=image, reasoning excluded) via a lazy ctx.ui.theme colorizer, keeping the
trigger/value/label and per-model route details intact.
…-005)

Pad the effective media-modality letter column to a shared width across the
visible suggestion rows so per-model route details start at the same column,
instead of variable-offset text that can cut into the capability column.
… (ADJ-005)

Image presence implies text, so the #-mentions show a single capability letter
(OpenRouter-style): text-only groups keep T, image-bearing groups show only I.
Shared modalityLetterRun gains hideTextWhenOtherMedia; the model list rows keep
the full T/I lexicon.
When a routed group carries an explicit modality override that excludes image,
the spawned child prompt now includes a Model Group capability ceiling notice
telling it to report a capability mismatch rather than silently work around it
(e.g. reading an image). The router exposes the override ceiling on the route
regardless of whether the caller declared a requirement. Advisory only: no
enforcement or model-input stripping yet (operator will test-drive before
extending scope).
When the operator explicitly names a specific model group (e.g. #quick-review)
and the task requires a capability that group lacks, the parent must NOT
substitute a different group, inherit the parent model, or work around the
missing capability. It stops and reports the mismatch, asking whether to pick a
different group or drop the capability. Aligns the system-prompt model-groups
guidance (index.ts modelGroupsPromptSection) with the spawn tool prompt
guidelines (spawn/index.ts SPAWN_PROMPT_GUIDELINES).
… picker chips (D2)

D1: when a #group spawn declares a modality requirement, resolveSpawnModelRoute now narrows the selection pool to members whose individual modality fact satisfies it, and round-robins across that capable set via an in-memory session cursor (state.spawnRouteCursors) instead of uniform random over all usable members. Unauthenticated models and non-capable members are excluded; overridden group ceilings still govern and wholly-incapable groups still reject missing-modality.

D2: the Add-model picker (WIZARD_MODEL) now renders a per-model colored T/I capability chip by reusing the shared modality lexicon and getModalitiesModelFact, so capability is visible while adding.
…the union

Automatic (no-override) groups now expose the union of their members'
individual modality facts as the effective capability set, instead of the
intersection. This fixes a real routing gate bug: an Automatic mixed group
[text, text+image] with an image spawn selected the image-capable member via
D1 pre-selection, but the group gate then saw effective=common=[text] and
rejected a missingFromGroup=image.

An explicit persisted override remains an authoritative subtractive ceiling
(text always present; filtered to each member's supported list), preserving
ADJ-005 Policy B and the named-group 'stops + reports' contract (e.g.
quick-review [text, reasoning] still rejects image spawns). common stays the
factual every-member editor field; only the reconcile default changes.

Battery: unit 688, snapshots 11, e2e 16, compat 16, package-host, audit-ci.
…ited modalities guidance

Model rows in the editor now render the same colored T/I chips as the
Add-model picker (D2), derived per-member from getModalitiesModelFact;
unresolved members render without a chip. The modalities hand-edit screen
gains two short dim guidance lines explaining Automatic (uses every
capability its members support) vs Override (limited to exactly the listed
capabilities) — the D3 union-default vs subtractive-ceiling semantics.
The modalities hand-edit screen drops the Automatic selector row and is now
built around disabling capabilities from the available union set:

  T text  [required]
→ I image  [off]

- Text is a non-toggleable required base row (always [required], matching the
  operator decision that text is present whenever any model is added).
- The remaining union media capabilities are [on]/[off] toggles.
- Toggling OFF a capability writes a subtractive override from the current
  effective (preserving hidden reasoning ceilings, e.g. image off on
  [text,image,reasoning] stores [text,reasoning]).
- Toggling the last capability back ON collapses to Automatic only when the
  ordered candidate equals the full union — so no override (and no spawn
  ceiling) is stored for a non-limit; entering Automatic remains fully
  reachable even without a selector row.
- A single dynamic status line replaces the two static guidance lines:
  'Automatic — using every capability its members support.' vs
  'Override — media limited to <media>.'
- Zero-toggle screens (text-only) show 'No optional media capabilities
  available.' + 'Esc back'; Enter/Space are inert.

Generic constraintEditorRows() and its constraint-layer test are unchanged;
only the TUI screen specializes. Tests updated for the new interaction
(override commit, re-enable-to-Automatic collapse, failure notify, dynamic
status) plus a new text-only inert-screen test. Battery: unit 690, snapshots
11, e2e 16, compat, package-host.
The [on]/[off] state of a capability row read like a fixed fact beside the
[required] text base row. Editable rows now append a dim hint so the image
capability is clearly interactive:

  T text  [required]
→ I image  [on] — Enter/Space toggles

The required text row and text-only screens are unchanged (no hint). Tests
extended: editable row shows the hint, required row does not, text-only
screen never shows it.
…t highlight

Per planner-design + operator calls:

- The per-row '— Enter/Space toggles' hint is removed (was noise on the typical
  T+I vocabulary).
- Single editable row screens drop '↑↓ navigate' — the footer is just
  'Enter/Space toggle • Esc back' (arrow-nav returns only if a second visible
  media row ever appears).
- The selected capability row is now accent-highlighted: accent arrow + accent
  label while the modality letter keeps its own color. Previously the
  selectableLine accent wrap was neutralized by inner color resets, so the
  row appeared unhighlighted.
- Text-only screens keep 'No optional media capabilities available.' + 'Esc back'
  with Enter/Space inert.

Tests: hint assertions replaced by compact-footer assertions; accent-token test
extended to MODALITIES asserting the highlight markup; width-bounded test
comment corrected (the assertion after the name row is MODALITIES, not
MODEL_EDIT). Battery: unit 690, snapshots 11, e2e 16, compat, package-host.
…ing#27)

- V2-A-01: fully-stale non-empty modality override falls back to derived
  union; explicit [] override stays text-only; subtractive ceiling kept
- V2-B-02: TUI cloneDef preserves opaque top-level group keys on edit
- V1-01: trim trailing whitespace in modalities diagnostic
- V2-B-01/V1-03: low-level save is shape-only, CRUD rejects union-cap
  invalid overrides; boundary locked by test
- V1-04: malformed legacy v1 modalityOverride loads opaquely, drops on v2
- V3-A-02: generic descriptor selection + descriptor-owned ceiling
  advisories (no image literal in spawn)
- test-only synthetic min-context and max-budget descriptors prove the
  capability-generic seam; production registry stays exactly {modalities}

Battery: typecheck, npm test 695/695, e2e 16/16, snapshots 11/11,
compat 166/166, package-host, audit-ci, git diff --check
…agenticoding#27)

- N-27-1: cursor round-robin engages only when declared requirements genuinely
  narrow the pool (capable < usable); routed empty {required:[]} returns to
  uniform RNG (regression fixed + routed test)
- F2-3: reset invariant now populates and asserts spawnRouteCursors cleared
- F2-2: spawn tool constraints schema built from the registry passed at
  registration (injected/test descriptors accepted at tool-schema level);
  whole-path test validates through real registered schema, not direct execute
- D-7: removed dead descriptor fields (persistence.clone, editor.format,
  editor.allowAutomatic); single-sourced MODEL_GROUP_MODALITY_PROSE; corrected
  reasoning-doc default
- readability: split modalities.ts nested ternary into explicit four cases

F2-1 (downgrade data-loss) intentionally left open per operator.
Battery: typecheck, npm test 697/697, e2e 16/16, snapshots 11/11, compat
167/167, package-host, audit-ci, git diff --check.
Split the single-line field declarations + nested-ternary message builder
into a documented class with one field per line, an explicit details
interface, and a branch-based describeRouteError helper. Public surface
(reason/group/missing*/constraintUnsatisfied/message strings) is unchanged
and asserted by existing router + spawn tests.

Battery: typecheck, unit 697/697, e2e 16/16, snapshots 11/11, compat
167/167, package-host, audit-ci, git diff --check.
…re/tui/types

Structure-only readability pass (no logic change, no new behavior):
- router: expand effectiveGroupMap loop, resolveSpawnModelRoute setup/pool/
  cursor/route construction, and modality-error extraction into named locals
- store: split backups, loadScope, mergeLoaded, normalizeGroups, saveModelGroups,
  CRUD (create/update/rename/delete/move), validateModelGroups into readable
  multi-line form; preserved error-wrapping order and phases
- tui: bind MODEL_EDIT lookup + decompose renderEditorComponent nested chain
- types: expand ModelGroupsPersistenceError ctor

Independent review caught and I fixed one real delta introduced mid-refactor:
moveGroup source-removal failure rewrap reported newScope path; restored to
oldScope sourcePath. F2-1 downgrade guard deliberately NOT added (store.ts
semantics unchanged).

Battery: typecheck, unit 697/697, e2e 16/16, snapshots 11/11, compat 167/167,
package-host, audit-ci, git diff --check.
Extract cohesive helpers (decodeDeclared, inheritedRoute, usableMembers,
selectMember, buildRoutedRoute, attachCeilings, collectViolations,
raiseRouteFailure) so the routing function drops from ~43 decision points to
~7. Stictly behavior-preserving: cursor round-robin semantics, capable-pool
fallback, RNG clamp, error taxonomy/messages, and empty-requirements guard
are unchanged and lock-blocked by the existing router + spawn suites.

Battery: typecheck, unit 697/697, e2e 16/16, snapshots 11/11, compat 167/167,
package-host, audit-ci, git diff --check.
@grzegorznowak
grzegorznowak marked this pull request as ready for review August 23, 2026 11:05
@grzegorznowak
grzegorznowak requested a review from ofriw August 23, 2026 11:05
README: add a plain-language Capabilities feature bullet and section covering
the T/I modality chips in pickers/editor rows, narrowable group sets, and
fail-early spawn constraint checks. Model Groups bullet unchanged.

CHANGELOG [Unreleased]: add capability-aware spawn routing + pluggable
capability kernel entries under Added, and per-member capability chips under
Changed. Modalities scoped to user-facing text/image chips.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant